Repository navigation
Read structs with defaults that hold a Validator without a valid default - #12
Open
clockblocker wants to merge 5 commits into
Open
clockblocker wants to merge 5 commits into
clockblocker wants to merge 5 commits into
Conversation
rfl::Variant() default-constructed its first alternative as a temporary and moved it into its storage. Construct it in place instead, which saves the move. This matters for the next commit: when a struct is read, the parser can defer the check of a Validator whose default value is invalid, but only if the Validator is constructed inside the struct. A temporary is not, so a struct with an rfl::Variant or rfl::TaggedUnion field whose first alternative holds such a Validator could not be read. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A struct that contains an rfl::DefaultVal, or that is read with
rfl::DefaultIfMissing, is default-constructed before it is read, so that
missing fields keep their default values. If a Validator in it has no
valid default value, as in
struct Person {
rfl::DefaultVal<std::string> name = "Homer Simpson";
rfl::Validator<std::vector<std::string>,
rfl::Size<rfl::Minimum<1>>> favorite_foods;
};
T{} throws, and the struct could not be read at all, even when the
input contains the field (upstream issue `munich-data-engineering#700`).
C++ has no way to get the default member initializer of one field
without constructing the whole aggregate, and Validator() can only tell
that it runs inside a read from state outside of it. So:
- read_struct_with_default still tries T{} first, exactly as before.
Only if that throws does it construct T{} again, in the same storage,
while an internal::DeferredDefaults is active on this thread.
- Validator() validates as before. Only if validation fails does it look
for an active DeferredDefaults. If the Validator lies inside the
storage of the struct being constructed, it keeps T() and records its
address and error instead of throwing. Validators anywhere else, such
as temporaries, on the heap or in a nested read, throw as before.
- After reading, a record inside a field that the input contains is
harmless, because the reader replaces such a field as a whole. A
record inside a missing field is the error "Field named 'x' not found,
and its default value is invalid: ...". A record outside any field
(not expected to happen) is the default-construction error.
- Once T{} needed this, later reads of T skip the first, throwing try.
T is constructed with placement new rather than returned from a helper,
because a trivially copyable T may be copied on return, which would
move the recorded Validators.
Outside of read, Validator() and T{} behave as before. A struct whose
T{} does not throw is read by the same steps as before.
The test of fork PR 11 used structs that can now be read. They now get
invalid default values that are moved into the struct, which cannot be
deferred.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
To put the default values of rfl::DefaultVal fields into a JSON schema, to_schema default-constructs the struct. If that throws, for instance because a Validator has no valid default value, the exception escaped rfl::json::to_schema, and for a nested struct it called std::terminate, because NamedTupleParser::to_schema is noexcept. Leave out the default values of such a struct instead. An invalid default value would not belong in the schema anyway, and the struct can be read since the previous commit. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Replace the section that said every field of a struct with defaults needs a valid default value. Such a field can now be read; it must not be missing. List the cases that still fail with the default-construction error and the copy that is not checked. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
test_default_val_with_validator_limits_fallback failed with MSVC. Its default member initializer catches the exception of a Validator and falls back to a valid value. While a struct is constructed with deferred validation, whether that exception is thrown depends on where the compiler constructs the returned Validator: outside of the struct (returned in registers under the Itanium ABI), where it throws as usual, or directly inside the struct (MSVC), where its validation is deferred like that of any other field. In the second case the read fails with an error if the field is missing from the input. The test now accepts both outcomes and checks that the read succeeds with every compiler if the input contains the field. The docs mention the case. Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Fixes munich-data-engineering#700 at the cost of the library's first thread_local.
The only other acceptable solution I found was to document the suggested workaround in the code and docs and leave it be.
AI-authored
Before and after
On
mainno input can be read into this struct. The parser buildsPerson{}before it looks at the input, and the emptyfavorite_foodsfails its own validator.A field without a valid default still has to come from the input:
rfl::json::read<Person, rfl::DefaultIfMissing>(R"({})");Outside a read,
Person{}andValidator()throw as before.Approaches
Defaults of missing fields exist only as default member initializers, which C++ evaluates only while constructing the whole struct. So the parser has to build
T{}before it reads, andValidator()has to return normally during that. It takes no arguments, so the parser cannot tell it who is calling.DefaultIfMissingand inPerson{}.Validator()never validates its defaultPerson{}in user code holds an invalid value without an error.Validatorcan hold "no value": aResult<T>(upstream PR#737) or a flagValidatorgrows and gets accessors that can fail.Validator()detects the parser withstd::source_location(upstream PR#707)DefaultIfMissingandDefaultVal<Validator>.include/, 647 of them the walk, which broke compilation for somerfl::Skipfields.= valuethread_local. +265 −30 lines ininclude/.Only the last one reads the issue's struct with no change to user code, leaves
Validator's layout and its behaviour outside a read alone, and still rejects a missing field whose default is invalid. It needs no type walk, because eachValidatorthat keeps an invalid default reports its own address.How it works
read_struct_with_defaultbuildsT{}in the result's storage, as onmain. If that succeeds, nothing else changes.T{}is built again while aDeferredDefaultsscope is active on this thread (RAII, restores the previous scope).Validator()whose default fails validation and whose address lies insideT's storage keepsT()and records its address and error. Every otherValidator()(temporaries, the heap, other threads) throws as before.details().std::atomic<bool>remembers thatT{}throws, so later reads skip step 1.Two related changes, each in its own commit:
rfl::Variant()constructs its first alternative in place, sorfl::Variantandrfl::TaggedUnionfields are covered.to_schema<Person>()threw onmain, and calledstd::terminatewhenPersonwas nested in another struct. It now returns a schema without default values.Limits
One case succeeds with an invalid value. A default member initializer that copies or reads a sibling
Validatorwith no valid default is not checked:Copies{}outside a read still throws. Closing this needs user-provided copy constructors onValidator, which would makeValidator<int, ...>non-trivially copyable for everyone. A test pins the behaviour and the docs state it.Everything else fails with an error:
rfl::DefaultVal<Positive> p = Positive();,rfl::Tuple<Positive, int>, a small struct returned in registers,-fno-elide-constructors) or on the heap (std::vector<Positive>(1),rfl::Box).Validatorand falls back to a valid value works with clang and GCC. MSVC builds thatValidatordirectly inside the struct, so it counts as deferred, and the read fails if the field is missing.rfl::Skip<Validator>without a valid default is never read, so the read always fails.T{}throws for another reason is constructed twice before the same error is returned.thread_local. AValidator()that runs in another DLL than the parser throws, and the read fails as onmain.Measurements
All against
main(e91ea28), macOS arm64, Apple clang 17 and Homebrew GCC 16.2.Run time at
-O3, in ns, median of 5 interleaved runs:mainmainPersonDefaultValstruct without aValidatorValidator<int>fieldsValidator<int, Minimum<0>>()Validator<std::string, Size<Maximum<10>>>(){}of a struct with four of those and oneintValidatorThe read rows vary by up to 12% between runs, so only the first row's delta is outside the noise. The last two rows are the real cost: the failure path of
Validator()can now return instead of only throwing, which costs 0.3 to 0.6 ns per default-constructedValidatorof a non-trivial type.Compile time and peak memory, clang
-O2 -c, median of 5 interleaved runs:mainmainDefaultValDefaultIfMissingDefaultIfMissingThat is 25 to 30 ms per struct type read with defaults. Of each time delta, 0.01 to 0.03 s comes from the base branch.
Tests
reflect-cpp-json-testsreflect-cpp-generic-testsreflect-cpp-cli-testsmaintoo:strtod_l)The new tests also pass with
-std=c++23 -DREFLECTCPP_USE_STD_EXPECTED(both compilers), with-fsanitize=address,undefinedand with-fsanitize=thread(clang).The fork has no CI. On the same commit (4c04f88), all 176 checks of the upstream CI pass. That covers Linux with GCC 11 to 14 and clang 16 to 18 (C++20 and C++23), MSVC with static and shared builds, macOS arm64 and Intel, and the test targets of all formats. Not tested: a
Validator()that runs in another DLL than the parser (see Limits).Stacking
Based on
fix-700-prereqs: fork PRs #9, #10 and #11, plus one commit that adapts a test of #10 to the message of #11. On that base the "before" message carries the prefix from #11 (Could not default-construct Person, ...).🤖 Generated with Claude Code